Fix func_vehicle and func_tracktrain engine sound bleeding into adjacent precached sounds - #1185
Merged
Merged
Conversation
Nord1cWarr1or
force-pushed
the
fix/vehicle-pitch-bleed
branch
2 times, most recently
from
August 24, 2026 19:20
7037987 to
3c0975f
Compare
…ent precached sounds
Nord1cWarr1or
force-pushed
the
fix/vehicle-pitch-bleed
branch
from
August 24, 2026 19:51
3c0975f to
1c84cf7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the
func_vehicle/func_tracktrainengine sound bleeding into adjacent precache entries (e.g. hostage voice lines playing instead of the vehicle engine at certain speeds).Fixes #1184
The Bug
When driving a
func_vehicleat certain speeds, the engine sound is replaced by whatever sound is next in the client's sound cache. On maps with hostages players hear "let's get out of here" (hostage/huse/getouttahere.wav) instead of the vehicle engine. Which sound bleeds in depends on the map's precache order — it is always the entry loaded right after the vehicle sound.snd_showconfirms the channel is still playing the correct sound (plats/vehicle4.wav), yet a different sound is heard.Root Cause
The defect itself is in the GoldSrc client engine (
hw.dll), not in the game DLL. GoldSrc mixes a sound channel through one of two paths:PITCH_NORMSND_PaintChannelFrom8)PITCH_NORMA sound started with a pitch other than 100 is wrapped into a single-word VOX "sentence" (
VOX_MakeSingleWordSentence) — that is what makes pitch shifting possible at all. The plain path has no bounds check whatsoever: it copiescountsamples from the channel's playback cursor and trusts the channel's end-of-wave marker completely.That marker is where the two paths disagree. It is maintained in mixing-clock units, but the pitch-shifting path corrects it in source-sample units. For 22 kHz sounds those units differ by 2x, because the mixer runs at 11 kHz and mixes 22 kHz sounds in a separate double-rate pass (
hisound 1, the default).So while the engine sound plays with a shifted pitch, the end-of-wave marker silently drifts ahead of the real sample data — by
2 * (1 - pitch / 100)samples per mixed sample, roughly 8800 samples per second at pitch 60. Nothing is audible yet: the pitch-shifting path will not read past the end of the wave no matter what the marker says. As soon as the reported pitch becomes exactly 100, the plain path takes over, follows the inflated marker past the end of the wave and mixes in whatever the sound cache holds next — up to about a second of foreign audio, which is why a whole recognisable voice line is heard instead of a click.hisound 0makes the bug disappear, which matches this explanation: the sound is then downsampled to the mixer rate, the double-rate pass is not used, and the unit mismatch is gone.Existing clients cannot be fixed, so the game DLL has to avoid the condition that triggers it.
The Fix
1. Never report a pitch of exactly
PITCH_NORMThis is what actually fixes the bleed: the channel stays on the pitch-shifting path, which is bounded by the length of the wave, and the unbounded plain path is never reached. A 1% pitch offset is inaudible.
This is the same guard Valve already applies to looping sounds whose pitch is modulated:
CFuncRotating::RampPitchVol()—pitch = PITCH_NORM - 1(bmodels.cpp)CAmbientGeneric—pitch = PITCH_NORM + 1, commented// don't send 'no pitch' !(sound.cpp)func_vehicleandfunc_tracktrainwere the two entities missing it.2. Send pitch/volume updates through
EMIT_SOUND_DYNinstead of the client eventThe event packs the pitch as
pitch / 10into 6 bits, so the client only ever sees multiples of 10 and every value in[100, 110)arrives as exactlyPITCH_NORM. With that encoding the nearest usable value to 100 is 110 — a full 10% step.EMIT_SOUND_DYNputs the pitch on the wire as a byte (SV_BuildSoundMsgwrites 8 bits), soPITCH_NORM - 1reaches the client unchanged; as a side effect the engine pitch also stops being quantized to steps of 10 across the whole range and the volume is no longer squeezed into 6 bits.This is the same call
CFuncRotatingalready uses.It also fixes two long-standing defects of the event path:
EMIT_SOUND_DYNonCHAN_STATIC, which the engine delivers as a reliable broadcast to everyone, while the stop event only reaches players nearby. Anyone who was elsewhere when the vehicle stopped kept hearing the engine loop forever.SND_STOPsent throughEMIT_SOUND_DYNis broadcast reliably — which is exactly why the engine special-casesSND_STOPandCHAN_STATIC.g_vecZero.EMIT_SOUND_DYNuses the entity's real position.StopSound()is updated the same way (EMIT_SOUND_DYN+SND_STOP).Everything is under
REGAMEDLL_FIXES; the originalPLAYBACK_EVENT_FULLpath is kept in the#elsebranch.PRECACHE_EVENT("events/vehicle.sc")is deliberately left in place so event indices do not shift for clients.3. Same treatment for
func_tracktrainCFuncTrackTrain::UpdateSound()/StopSound()contain the same code and the same bug. The train also had no upper pitch clamp at all, so one is added underREGAMEDLL_FIXES(TRAIN_MAXPITCH) — without it a fast enough train would push the pitch past the byte range the sound message carries.Why both parts
Part 1 is the actual fix. Part 2 is what makes part 1 usable: through the event encoding the closest reachable value to 100 is 110, a 10% jump; through
EMIT_SOUND_DYNit is 99, a 1% offset nobody can hear. Part 2 additionally fixes the two delivery problems listed above.Behaviour changes
func_vehicle/func_tracktrainare no longer sent asevents/vehicle.sc/events/train.sc. Plugins hookingFM_PlaybackEventfor these will no longer see them — they now show up asFM_EmitSoundinstead.svc_soundonCHAN_STATIC, which the engine broadcasts reliably to all players rather than only to those in PAS. That is roughly 15 bytes per second per moving vehicle per client. In exchange, stop messages can no longer be missed.Testing
Tested on
awesome_cars. Thegetouttahere.wavbleed is gone. Verified on a vanilla client as well: with only the server-side change applied and an unpatched client, the bleed no longer occurs. Some crackling remains at speed transitions — that is a separate sound quality issue, not the precache bleed.References
PITCH_NORMavoidance already present inbmodels.cpp(CFuncRotating) andsound.cpp(CAmbientGeneric)SV_StartSound/SV_BuildSoundMsgin ReHLDS